Skip to content

Migrate pipeline from CircleCI to GitHub Actions#157

Merged
phelma merged 2 commits into
mainfrom
gha-migration
Jul 23, 2026
Merged

Migrate pipeline from CircleCI to GitHub Actions#157
phelma merged 2 commits into
mainfrom
gha-migration

Conversation

@phelma

@phelma phelma commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Cutover to GitHub Actions per the Variant A family plan (gem pilot).
Includes decommission — merging this PR completes the repo's migration.

  • main + pr workflows: check/test, prerelease, release environment gate
  • PR CI publishes a namespaced pre-release to RubyGems (see below)
  • git-crypt unlock on the runner; encrypted CI GPG key moved to .github/
  • Slack notifications via rake_slack; dependabot auto-merge job
  • Rakefile provisioning swapped to rake_github secrets/environments; rake_circle_ci dropped
  • CircleCI pipeline removed: .circleci/, scripts/ci/, the CI SSH deploy
    key pair and its keys:deploy/deploy_keys provisioning, and the stored
    CircleCI/GitHub API credentials (config/secrets/{circle_ci,github}/)

Deliberate decisions (not defects)

This cutover reproduces the CircleCI pipeline's behaviour, warts included;
fixing inherited hazards is post-migration work. In particular:

  • ./go release publishes to RubyGems before the version-bump commit is
    pushed — pre-existing ordering inside the untouched release logic.
  • Prerelease publishes on every push to main with no approval gate; only
    full releases are gated (environment: release).
  • Dependabot auto-merge accepts any update type that passes checks, and the
    merge does not trigger a release build — on CircleCI the merge commit
    carried [skip ci], so this matches. Updates ship with the next
    human-triggered release.
  • The release job pulls main at approval time, so a delayed approval
    publishes main as it stands then, not the SHA this run tested — parity with
    the old release.sh (which also pulled; prerelease.sh did not, so the
    prerelease job has no pull).
  • asdf_install@v1 is our own action (infrablocks/github-actions); we are
    happy tracking its major version tag.
  • Job scaffolding is repeated flat per job by design: the logic lives in the
    build system (./go/rake) and CI stays lean — it just triggers tasks and
    supplies secrets/context.
  • Gemfile.lock carries transitive major bumps — the unavoidable resolution
    of the targeted bundle lock --update, not scope creep.
  • Small library hunks may appear where the refreshed toolchain's rubocop
    autocorrects existing code (e.g. Style/ArgumentsForwarding) — required
    by the library:check verification gate, not drive-by refactoring.
  • Provisioning (pipeline:prepare) authenticates with the operator's ambient
    gh login (GITHUB_TOKEN fallback) instead of a stored PAT — a deliberate
    parity deviation; the stored token in config/secrets/github/config.yaml
    is deleted with the rest of the CircleCI-era credentials.

PR-CI prerelease publish (deliberate, permanent)

pr.yaml has a prerelease job that publishes a namespaced pre-release of
this gem to RubyGems from the PR branch — a permanent CI feature, not
migration-only. This is a deliberate deviation from CircleCI (which published
nothing pre-merge): it proves the publish path before merge instead of
discovering it broken on main. The version is
<committed-version>.pr<PR>.<run>.<attempt> (via the new prerelease:publish
Rakefile task), so it can never collide with main's version:bump[pre]
sequence; the task builds the gem and pushes it straight to RubyGems, then
restores version.rb, so nothing is committed, tagged, or pushed
(gem release is not used — it aborts on the uncommitted version rewrite).
The job is skipped for fork
and Dependabot PRs (they hold no secrets), and merge-pull-request does not
depend on it. PR pre-release versions accumulate permanently on RubyGems —
accepted.

Do not merge manually — the pipeline merges once checks are green.
Disabling the CircleCI project and deleting the CircleCI deploy key are
deferred to the end-of-migration sweep.


🏭 This PR was opened by Foundry, Atomic's AI software development
factory. Implementation, review, and fixes are performed by AI agents;
merges happen automatically once the review and checks gates pass.
This task migrates a Ruby gem's CI from CircleCI to GitHub Actions.

Pipeline Task pack Family Run
migrate migrate-gem gem 2026-07-22T17-48-59-192Z

atomic-foundry-pr · foundry-pipeline: migrate · foundry-task: migrate-gem · foundry-run: 2026-07-22T17-48-59-192Z

@phelma phelma left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: #157 - Migrate pipeline from CircleCI to GitHub Actions

Verdict: REQUEST_CHANGES

A careful, well-hardened CircleCI→GitHub Actions cutover that faithfully follows the Variant A family plan: least-privilege permissions, env-indirection for untrusted inputs, git-crypt ciphertext guards, a release environment gate, and thorough "why" comments. One in-scope defect blocks approval: both workflows invoke ./go spec, a rake task that does not exist in this repo (the only test task is test:unit), so the required test gate fails on every push and PR and the whole pipeline stalls. Everything else the lenses raised is a concern against a decision the plan/PR documents as deliberate — kept below for a human to revisit at the plan level; none of it blocks a plan-conformant diff.

Cross-Cutting Themes

  • Non-existent spec test task (correctness, standards) — both main.yaml and pr.yaml run ./go spec; the Rakefile defines only test:unit. The plan (§2.3, §5.2) specifies test:unit, and sibling rake_slack uses it. ./go spec was a pre-existing broken invocation in the old test.sh (the spec task was removed from the Rakefile long ago), but the migration should have adopted the plan-mandated test:unit, which exists and passes. As written the test job errors with Don't know how to build task 'spec'.

Strengths

  • ✅ Least-privilege permissions: contents: read with narrow per-job escalation.
  • ✅ Untrusted inputs passed via env: as $VARS, never ${{ }}-interpolated into run:.
  • ✅ Secret-bearing PR prerelease job guarded to same-repo, non-Dependabot PRs.
  • ✅ Dependabot merge uses --match-head-commit "$HEAD_SHA" and immutable user.login.
  • read_encryption_passphrase refuses git-crypt ciphertext; resolve_github_token fails fast.
  • prerelease:publish restores version.rb and removes the built gem in an ensure block.

General Findings

  • 🔵 code-quality (suggestion, in-scope): token/passphrase logic was extracted into top-level resolve_github_token / read_encryption_passphrase methods; the plan (§4.4 step 3) shows this inline and says "Do not restructure anything else". Functionally identical, cleaner, likely helps Metrics/BlockLength — a benign deviation, noted for transparency.

Plan Concerns (documented-deliberate — do not block; for plan revisit)

  • 🟡 security: the same-repo PR prerelease job hands the RubyGems token and git-crypt secrets to PR-branch code; a write-access contributor could exfiltrate them with no maintainer approval. This is the D8 pre-merge publish, documented and guarded to same-repo human PRs. (pr.yaml:85)
  • 🔵 security: Dependabot auto-merge accepts any update type — D3, documented parity.
  • 🔵 security: provisioning uses the operator's ambient gh/GITHUB_TOKEN, not a stored PAT — D7, documented parity.
  • 🔵 safety: ./go release publishes to RubyGems before git push — a pre-existing hazard inside untouched ./go release logic (§1, D5). (main.yaml:124)
  • 🔵 standards: concurrency.queue: max — the plan §4.2 specifies it and cites the 2026-05-07 GA changelog; authoritative here. (main.yaml:54)
  • 🔵 standards: notify idiom (!cancelled() + continue-on-error) differs from rake_slack's always() — plan §3 says the §4 YAML is authoritative.
  • 🔵 standards: main.yaml omits the sibling's skip-ci-check job — D4 states no loop-guard job is needed.
  • 🔵 code-quality: gem build duplicated across library:build / prerelease:publish; repeated arg list; magic Slack channel IDs — all plan-prescribed verbatim (§4.4).

Non-issue (verified)

  • prerelease:publish's ensure restore not being SIGKILL-safe self-nullifies: the runner checkout is ephemeral, so a mutated version.rb never persists.

Review generated by /accelerator:review-pr

Comment thread .github/workflows/main.yaml Outdated
- name: Install tools
uses: infrablocks/github-actions/asdf_install@v1
- name: Test
run: ./go spec

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Correctness / Standards (critical)

The Test step runs ./go spec, but this Rakefile defines no spec task — the only RSpec task is test:unit (RSpec::Core::RakeTask.new(:unit) inside namespace :test). bundle exec rake spec aborts with Don't know how to build task 'spec'. Because prerelease needs [check, test], every push to main stops here and the release path never runs.

The family plan (§2.3, §5.2) specifies test:unit, and the sibling rake_slack workflow uses it. ./go spec was a stale, already-broken invocation carried over from the old test.sh (the spec task was removed from the Rakefile long ago).

Fix: change to ./go test:unit (also in pr.yaml), or add task spec: 'test:unit' to the Rakefile.

Comment thread .github/workflows/pr.yaml Outdated
- name: Install tools
uses: infrablocks/github-actions/asdf_install@v1
- name: Test
run: ./go spec

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Correctness / Standards (critical)

Same defect as main.yaml:35: ./go spec is not a defined task (only test:unit exists). Every PR fails the test job, and both prerelease and merge-pull-request depend on [check, test, build], so PR prereleases and Dependabot auto-merge can never run.

Fix: change to ./go test:unit.

Comment thread .github/workflows/pr.yaml
run: ./scripts/ci/common/configure-rubygems.sh
- name: Publish prerelease
# Facts via env, never interpolated
run: ./go "prerelease:publish[$PR_NUMBER,$RUN_NUMBER,$RUN_ATTEMPT]"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Security (major) — plan concern, not a blocking defect

This job unlocks git-crypt and runs prerelease:publish on the PR branch's own code, guarded only by same-repo + not-Dependabot. A contributor who can push a branch could modify the gemspec or the prerelease:publish task in the same PR to exfiltrate the RubyGems push token and git-crypt secrets, with no maintainer approval.

This is the deliberate D8 pre-merge publish, documented in the plan and PR description. Flagged so a human can decide whether to gate the publish behind a reviewed environment: at the plan level — it does not block this plan-conformant diff.

run: ./go repository:set_ci_author
- name: Bump version
run: ./go "version:bump[minor]"
- name: Release

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Safety (minor) — plan concern

./go release (gem release --tag --push) publishes to RubyGems before the Push commits/Push tags steps. A push failure leaves a public, effectively unyankable version while main never records the bump — the next run recomputes the same version and gem push fails on a duplicate, wedging releases.

This is a pre-existing, deliberately-preserved hazard inside the untouched ./go release logic (§1, D5). No change required in this migration.

concurrency:
group: main
cancel-in-progress: false
queue: max

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Standards (minor) — plan concern

concurrency.queue: max is a newer Actions key not present in the sibling rake_slack workflows; if the runner's schema does not honour it, the queue-all-runs intent silently degrades.

The plan §4.2 specifies this key verbatim and cites the 2026-05-07 GA changelog, so it is authoritative here — noted only so a human can confirm the feature is live for this org.

@phelma phelma left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: #157 — Migrate pipeline from CircleCI to GitHub Actions

Verdict: REQUEST_CHANGES

A clean, faithful Variant A cutover — task wiring is sound, secrets handling is well-hardened (PR-controlled values routed through env, secret-bearing prerelease job guarded to same-repo human PRs, immutable user.login dependabot guard, least-privilege permissions:), and the decommission is complete. One in-scope critical defect blocks approval: the release job runs ./go documentation:update, but no such task exists in the Rakefile and the deleted release.sh never invoked it — plan §4.2 says this step must be omitted when release.sh does not run it. Several lens findings target the plan's own authoritative YAML/code (queue: max, gpg path, verbatim comments) and are recorded as plan concerns, not defects.

Cross-Cutting Themes

  • queue: max concurrency key (correctness, safety) — both lenses believe it is an unsupported GitHub Actions concurrency key. Plan §4.2 specifies it verbatim, citing a GA feature dated 2026-05-07 → plan concern, not a defect. Worth a human confirming the feature shipped as cited.

Strengths

  • ✅ Every ./go task referenced resolves to a real task (except documentation:update, below); slack:notify[outcome,type] arity matches; gpg path matches rake_git_crypt's default.
  • ✅ PR title/number/SHA/URL routed through env vars, not ${{ }} shell interpolation; only ${{ job.status }} is inline (trusted enum).
  • ✅ Prerelease (secret-bearing) job guarded to same-repo, non-dependabot PRs; dependabot merge guard uses immutable pull_request.user.login.
  • ✅ Least-privilege permissions:; environment: release gate present; all jobs have timeout-minutes; --match-head-commit guards the merge.
  • prerelease:publish restores version.rb and removes the built gem in an ensure block; requires/Gemfile stay alphabetical; helpers fail fast.

General Findings

  • 🔴 Correctness (plan-conformance): release job Update documentation step references a non-existent task; release.sh never ran it. Breaks every release. See inline.
  • 🔵 Code Quality: gem name / gem build command duplicated across library:build and prerelease:publish (minor).
  • 🔵 Standards: helper defs placed mid-file between namespaces (suggestion; a clean divergence from the plan's inline code).
  • 🔵 Code Quality: prerelease:publish validates arg presence but not numeric shape (suggestion).
  • 🔵 Plan-conformance (minor): Gemfile.lock PLATFORMS still lists operator-local darwin platforms (§4.5 asks for none) — pre-existing and unchanged by this PR.

Plan Concerns (documented-deliberate; do not block)

  • 🔵 queue: max on prerelease + release (correctness ×2, safety ×1) — plan §4.2 verbatim, cites GA 2026-05-07.
  • 🔵 Shared main concurrency group across gated release + ungated prerelease (safety) — the plan's explicit design.
  • 🔵 Compound prerelease version 0.13.0.pre.2.pr<N>... (correctness) — plan §4.4 step 8 documents this shape as valid/accepted.
  • 🔵 gpg key at .github/gpg.private.enc root (standards) — plan §4.3 mandates this exact path.
  • 🔵 pr.yaml comments exceed 80 cols (standards) — verbatim from the plan's YAML; YAML is not rubocop-linted.
  • 🔵 String#sub replacement-string backslash handling (code-quality) — plan's verbatim code; inputs numeric, safe.

Review generated by /accelerator:review-pr

- name: Bump version
run: ./go "version:bump[minor]"
- name: Release
run: ./go release

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Correctness / plan-conformance (critical) — Release job runs a non-existent documentation:update task.

This step runs ./go documentation:update, but there is no documentation:update task anywhere in the Rakefile or the loaded gems, and the deleted scripts/ci/steps/release.sh only ran version:bump[minor] + release — it never ran documentation generation. Plan §4.2 is explicit: "include the whole Update documentation step only if the repo's release.sh runs it; omit the step otherwise." This repo's release.sh does not, so the step must be removed. As written, every release run fails here with Don't know how to build task 'documentation:update'.

Fix: delete the Update documentation step from the release job.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is false, ./go documentation:update does not exist.

concurrency:
group: main
cancel-in-progress: false
queue: max

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Plan concern (not a defect)queue: max.

Recording for a human: the correctness and safety lenses flag queue: max as an unsupported GitHub Actions concurrency key (only group / cancel-in-progress are historically valid). However, plan §4.2 specifies queue: max verbatim and cites a GA feature dated 2026-05-07. Treated as documented-deliberate and not counted as a defect — but worth confirming the feature shipped as cited, since otherwise intermediate prerelease runs may be silently coalesced.

Comment thread Rakefile
# Build + push directly: `gem release` aborts on the (deliberately)
# uncommitted version rewrite. PR CI must not tag, commit, or push
# (contrast the `release` task).
sh 'gem build rake_ssh.gemspec'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Code Quality (minor) — Gem name / build command duplicated.

gem build rake_ssh.gemspec appears in both library:build and prerelease:publish, and rake_ssh is hardcoded again when composing gem_file. Three literals that must stay in sync on a rename. Consider a single GEM_NAME constant. Low priority.

Comment thread Rakefile
gem_file = "rake_ssh-#{version}.gem"
begin
File.write(version_file,
source.sub(version_pattern, "\\1#{version}\\3"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Plan concern (low)String#sub replacement string interprets backslashes.

source.sub(version_pattern, "\\1#{version}\\3") uses replacement-string semantics where a \N in version would be read as a backreference. Inputs are numeric here, so it is safe; this is the plan's verbatim code, recorded for a human. The block form (sub(version_pattern) { "#{$1}#{version}#{$3}" }) would avoid the coupling.

@phelma

phelma commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Plan §4 conformance sweep (independent of the lens review)

Both directions checked — required changes present, nothing beyond the plan changed. Diff matches the Variant A plan closely. Two deviations found:

  • 🔴 §4.2 (critical, in-scope): the release job includes an Update documentation step (./go documentation:update) even though this repo's release.sh never ran it and no such task exists. §4.2 requires that step be omitted unless release.sh runs it. Flagged inline on main.yaml; breaks every release. Remove the step.
  • 🔵 §4.5 (minor, pre-existing): Gemfile.lock PLATFORMS still lists operator-local darwin platforms (arm64-darwin-21/22, x86_64-darwin-19/20/21). §4.5 asks for none, but these are identical on base main — unchanged by this PR, so out of the diff's scope; noting for a future cleanup.

Confirmed correct against the plan: gemspec needed no edits (no rake_circle_ci/rake_ssh dev deps there — dev deps live in the Gemfile, which was updated correctly); keys:deploy/RakeSSH/ssh.*/deploy-key removals all present (this repo is rake_ssh, so it self-used those); version:bump[minor] correctly mirrors release.sh; prerelease.sh (pre + release, no pull) correctly mirrored; git-crypt move byte-identical; decommission complete; token/passphrase provisioning wired per D7.

The queue: max, gpg-path, compound-version and verbatim-comment findings from the lens review are plan concerns (they match the plan's authoritative YAML/§ text), not defects — kept in the review for a human to revisit the plan, not blocking.

@phelma
phelma marked this pull request as ready for review July 23, 2026 08:01
@phelma
phelma merged commit 05f1da2 into main Jul 23, 2026
5 of 6 checks passed
@phelma
phelma deleted the gha-migration branch July 23, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant